/* eslint-disable @typescript-eslint/no-explicit-any */
import { applyFunctionToKeysAndValues, isNil, isString } from '@openops/shared';
import { initCodeSandbox } from '../core/code/code-sandbox';
import { FlowExecutorContext } from '../handler/context/flow-execution-context';
import { createConnectionService } from '../services/connections.service';

const VARIABLE_PATTERN = /\{\{(.*?)\}\}/g;
const CONNECTIONS = 'connections';
const FLATTEN_NESTED_KEYS_PATTERN = /\{\{\s*flattenNestedKeys(.*?)\}\}/g;

type PropsResolverParams = {
  engineToken: string;
  projectId: string;
  apiUrl: string;
};

export const createPropsResolver = ({
  engineToken,
  projectId,
  apiUrl,
}: PropsResolverParams) => {
  return {
    resolve: async <T = unknown>(
      params: ResolveInputParams,
    ): Promise<ResolveResult<T>> => {
      const { unresolvedInput, executionState } = params;
      if (isNil(unresolvedInput)) {
        return {
          resolvedInput: unresolvedInput as T,
          censoredInput: unresolvedInput,
        };
      }
      const currentState = executionState.currentState();
      const resolveOptions = {
        engineToken,
        projectId,
        apiUrl,
        currentState,
      };
      const resolvedInput = await applyFunctionToKeysAndValues<T>(
        unresolvedInput,
        (token) =>
          resolveInputAsync({
            ...resolveOptions,
            input: token,
            censoredInput: false,
          }),
      );
      const censoredInput = await applyFunctionToKeysAndValues<T>(
        unresolvedInput,
        (token) =>
          resolveInputAsync({
            ...resolveOptions,
            input: token,
            censoredInput: true,
          }),
      );
      return {
        resolvedInput,
        censoredInput,
      };
    },
  };
};

const mergeFlattenedKeysArraysIntoOneArray = async (
  token: string,
  partsThatNeedResolving: string[],
  resolveOptions: Pick<
    ResolveInputInternalParams,
    'engineToken' | 'projectId' | 'apiUrl' | 'currentState' | 'censoredInput'
  >,
) => {
  const resolvedValues: Record<string, unknown> = {};
  let longestResultLength = 0;
  for (const tokenPart of partsThatNeedResolving) {
    const variableName = tokenPart.substring(2, tokenPart.length - 2);
    resolvedValues[tokenPart] = await resolveSingleToken({
      ...resolveOptions,
      variableName,
    });
    if (Array.isArray(resolvedValues[tokenPart])) {
      longestResultLength = Math.max(
        longestResultLength,
        resolvedValues[tokenPart].length,
      );
    }
  }
  const result = new Array(longestResultLength).fill(null).map((_, index) => {
    return Object.entries(resolvedValues).reduce((acc, [tokenPart, value]) => {
      const valueToUse = (Array.isArray(value) ? value[index] : value) ?? '';
      acc = acc.replace(
        tokenPart,
        isString(valueToUse) ? valueToUse : JSON.stringify(valueToUse),
      );
      return acc;
    }, token);
  });
  return result;
};

export type PropsResolver = ReturnType<typeof createPropsResolver>;

/**
 * input: `Hello {{firstName}} {{lastName}}`
 * tokenThatNeedResolving: [`{{firstName}}`, `{{lastName}}`]
 */
async function resolveInputAsync(
  params: ResolveInputInternalParams,
): Promise<unknown> {
  const { input, currentState, engineToken, projectId, apiUrl, censoredInput } =
    params;
  const tokensThatNeedResolving = input.match(VARIABLE_PATTERN);
  const inputContainsOnlyOneTokenToResolve =
    tokensThatNeedResolving !== null &&
    tokensThatNeedResolving.length === 1 &&
    tokensThatNeedResolving[0] === input;
  const resolveOptions = {
    engineToken,
    projectId,
    apiUrl,
    currentState,
    censoredInput,
  };

  if (inputContainsOnlyOneTokenToResolve) {
    const trimmedInput = input.trim();
    const variableName = trimmedInput.substring(2, trimmedInput.length - 2);
    return resolveSingleToken({
      ...resolveOptions,
      variableName,
    });
  }
  const inputIncludesFlattenNestedKeysTokens = input.match(
    FLATTEN_NESTED_KEYS_PATTERN,
  );
  if (
    !isNil(inputIncludesFlattenNestedKeysTokens) &&
    !isNil(tokensThatNeedResolving)
  ) {
    return mergeFlattenedKeysArraysIntoOneArray(
      input,
      tokensThatNeedResolving,
      resolveOptions,
    );
  }

  return replaceAsync(
    input,
    VARIABLE_PATTERN,
    async (_fullMatch, variableName) => {
      const result = await resolveSingleToken({
        ...resolveOptions,
        variableName,
      });
      return isString(result) ? result : JSON.stringify(result);
    },
  );
}

async function resolveSingleToken(
  params: ResolveSingleTokenParams,
): Promise<unknown> {
  const { variableName, currentState } = params;
  const isConnection = variableName.startsWith(CONNECTIONS);
  if (isConnection) {
    return handleConnection(params);
  }
  return evalInScope(variableName, { ...currentState });
}

async function handleConnection(
  params: ResolveSingleTokenParams,
): Promise<unknown> {
  const { variableName, engineToken, projectId, apiUrl, censoredInput } =
    params;

  if (censoredInput) {
    return '**REDACTED**';
  }

  const connectionName = parseConnectionNameOnly(variableName);
  if (isNil(connectionName)) {
    return '';
  }

  const connection = await createConnectionService({
    engineToken,
    projectId,
    apiUrl,
  }).obtain(connectionName);
  const pathAfterConnectionName = parsePathAfterConnectionName(
    variableName,
    connectionName,
  );
  if (isNil(pathAfterConnectionName) || pathAfterConnectionName.length === 0) {
    return connection;
  }

  return evalInScope(pathAfterConnectionName, { connection });
}

function parsePathAfterConnectionName(
  variableName: string,
  connectionName: string,
): string | null {
  if (variableName.includes('[')) {
    return variableName.substring(`connections.['${connectionName}']`.length);
  }
  const cp = variableName.substring(`connections.${connectionName}`.length);
  if (cp.length === 0) {
    return cp;
  }
  return `connection${cp}`;
}

function parseConnectionNameOnly(variableName: string): string | null {
  const connectionWithNewFormatSquareBrackets = variableName.includes('[');
  if (connectionWithNewFormatSquareBrackets) {
    return parseSquareBracketConnectionPath(variableName);
  }
  // {{connections.connectionName.path}}
  // This does not work If connectionName contains .
  return variableName.split('.')?.[1];
}

function parseSquareBracketConnectionPath(variableName: string): string | null {
  const matches = variableName.match(/\['([^']+)'\]/g);
  if (matches && matches.length >= 1) {
    return matches[0].replace(/\['|'\]/g, '');
  }
  return null;
}

// eslint-disable-next-line @typescript-eslint/ban-types
async function evalInScope(
  js: string,
  contextAsScope: Record<string, unknown>,
): Promise<unknown> {
  try {
    const codeSandbox = await initCodeSandbox();

    const result = await codeSandbox.runScript({
      script: js,
      scriptContext: contextAsScope,
    });
    return result ?? '';
  } catch (exception) {
    console.warn('[evalInScope] Error evaluating variable', exception);
    return '';
  }
}

async function replaceAsync(
  input: string,
  searchValue: RegExp,
  replacer: (...args: any[]) => Promise<string>,
): Promise<string> {
  try {
    const values: Array<Promise<string>> = [];

    input.replace(searchValue, (...args) => {
      values.push(replacer(...args));
      return '';
    });

    const resolvedValues = await Promise.all(values);

    return input.replace(searchValue, () => resolvedValues.shift() as string);
  } catch (error) {
    return Promise.reject(error);
  }
}

type ResolveSingleTokenParams = {
  variableName: string;
  currentState: Record<string, unknown>;
  engineToken: string;
  projectId: string;
  apiUrl: string;
  censoredInput: boolean;
};

type ResolveInputInternalParams = {
  input: string;
  engineToken: string;
  projectId: string;
  apiUrl: string;
  censoredInput: boolean;
  currentState: Record<string, unknown>;
};

type ResolveInputParams = {
  unresolvedInput: unknown;
  executionState: FlowExecutorContext;
};

type ResolveResult<T = unknown> = {
  resolvedInput: T;
  censoredInput: unknown;
};
